iT邦幫忙

2023 iThome 鐵人賽

DAY 15
1
Mobile Development

Flutter 從零到實戰 - 30 天の學習筆記系列 第 15

[Day 15] 實戰新聞 APP - 滾動式widget (ListView 、GridView與 Sliver widget)

  • 分享至 

  • xImage
  •  

今天我們將從「探索頁面」開始做起,我們將使用到兩個於 Flutter 中很常被使用於顯示需滾動內容的 widget,也就是 ListViewGridView 。我們先來看看今天的目標:

https://ithelp.ithome.com.tw/upload/images/20230930/20135082xeU8ulbEFN.png

導向分頁內容

在開始製作探索的頁面之前,先看看我們昨天在 tab_layout.dart 中的實作內容,顯示四個不同 tab 時,我們僅製作了各自顯示於頂端工具列的文字,卻仍未為每個頁面提供獨立的分頁內容。因此我們先來實作此部分,使得導覽列可以成功導向分頁的頁面。

請先在 screens 資料夾底下建立四個檔案:home_screen.dartbrowse_screen.dartsearch_screen.dartprofile_screen.dart,並先建立為 stateless widget。舉 home_screen.dart 為例:

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // 我們等等會介紹這是什麼
    return const SliverToBoxAdapter(
      child: Text('Home Screen')
    );
  }
}

接著請開啟 tab_layout.dart 的檔案中,我們將依據現有的 tabIndex 回傳正確的頁面,請參考以下函式:

// 根據傳入的 tabIndex 回傳相應的頁面,頁面型態為 widget
Widget getTabScreen(int tabIndex) {
  switch (tabIndex) {
    case 0:
      return const HomeScreen();
    case 1:
      return const BrowseScreen();
    case 2:
      return const SearchScreen();
    case 3:
      return const ProfileScreen();
    default:
      return const HomeScreen();
  }
}

透過呼叫上述的函式,就可以成功導向我們所需要的各個分頁拉~

tabBuilder: (BuildContext context, int index) => 
  CupertinoPageScaffold(
    child: CustomScrollView(slivers: <Widget>[
      CupertinoSliverNavigationBar(
        largeTitle: Text(tabTitle[index]),
        backgroundColor: CupertinoColors.white),
      getTabScreen(index),
  ]))

滾動式 widget

當我們所要顯示的內容無法於螢幕上一次性的展示時,就需要透過滾動來使內容可以繼續展示。

在 Flutter 中定義了一系列的滾動式組件來讓我們達成此一操作,包括接下來要介紹的 ListViewGridViewSliver widget 等等。

ListView

按照我們的設計圖原先應先介紹 GridView 再介紹 ListView,不過我還是想先講這個,比較好入門 XD

ListView 為用於顯示列表的 widget,也是在實現捲動內容時最常使用的 widget,顯示內容會一個接著一個的於捲動的方向上出現,可水平方向或垂直方向的實現。

ListView 的類別定義中,提供了四種建構子(含本身與 3 種命名建構子)

  1. ListView:為最基本的建構子,通過 children 參數接受一個 List<Widget> 作為子元素,並預設將其按照垂直方向排列。適合用於當子元素數量較少時的場景,因為其子元素於建構列表的同時也會一併建立完成。
ListView(
  children: <Widget>[
    Text('Item 1'),
    Text('Item 2'),
    // 更多項目...
  ],
)

2. ListView.builder:用於動態生成子元素的 ListView,透過 itemBuilder 建構子元素內容。適合用於建構當子元素數量較大的情境,因為該建構子僅會對實際顯示於螢幕上的內容進行調用。

ListView.builder(
  itemCount: 100, // 子元素的總數
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text('Item $index'));
  },
)
  1. ListView.separated:當子元素間需要分隔時可以使用此建構子,除了透過 itemBuilder 來建構子元素外,也有 separatorBuilder 來建構分隔的樣式。
ListView.separated(
  itemCount: 100,
  separatorBuilder: (BuildContext context, int index) {
    return SizedBox(
      height: 0.5, 
      child: Container(color: CupertinoColors.systemGrey)
    );
  },
  itemBuilder: (BuildContext context, int index) {
    return ListTile(title: Text('Item $index'));
  },
)
  1. ListView.custom:允許自定義列表形式,接收一個 SliverChildDelegate 來動態的生成子元素。
ListView.custom(
  childrenDelegate: SliverChildBuilderDelegate(
    (BuildContext context, int index) {
      return ListTile(title: Text('Item $index'));
    },
    childCount: 100,
  ),
)

此單元我們將使用 ListView 來進行練習,請打開 browse_screen.dart 參考以下程式碼:

@override
Widget build(BuildContext context) {
  return SliverToBoxAdapter(
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start, children: [
      const Padding(
        padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
        child: Text('新聞來源',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500)),
      ),
      const Padding(
        padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 6),
        child: Text('檢視所有新聞來源',
            style: TextStyle(
                fontSize: 16,
                fontWeight: FontWeight.w500,
                color: CupertinoColors.systemGrey)),
      ),
      // 放置 ListView 的位置
      ListView(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        children: [
          Text("暫時放一個 Text 看看會發生什麼事情"),
        ]
      );
  ]));
}

上述程式碼我們先使用 Column 來排版我們內部的 widgets,並建構了標題與說明文字。接著使用了 ListView 的建構子,並指定滑動為水平方向,以及與周圍的間距。

當你滿心歡喜想說可以跑了的時候,一執行卻發現整個應用程式卡死了... 為什麼呢?如果你仔細去觀看主控台回報的錯誤訊息會看到一行「Horizontal viewport was given unbounded height.」的文字。

引發原因是我們在此頁面最外層使用了 Column 的排版組件,其組件的高度是根據放置於其中的子組件來決定,但其中的 ListView 的垂直高度若沒有外部來的 constraint 會使得預設為無限長。也就變向導致 Column 的高度沒有極限而產生了unbounded height 的錯誤。

所以解決方式很簡單,就是藉由控制 ListView 可使用的高度使得 Column 能夠得到限制。可以這樣寫:

SizedBox(
  height: 200,
  ListView(...),
)

藉由 SizedBox 來設定高度為 200,來限制 ListView 最大可使用高度,這也就是我們在前面的篇章中有提到過的 constraint。如此便能成功的運行囉~

接著讓我們來實作新聞來源的子元素的樣子吧!請將以下的程式碼複製個 4 份至 ListViewchildren 吧:

Padding(
  // 每個子元素都間隔右邊 16 單位
  padding: const EdgeInsets.fromLTRB(0, 0, 16, 0),
  child: Column(
    // 子元素分成兩部分,圓角灰底的方形將用於顯示圖片 與 新聞來源的文字
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Container(
        width: 100,
        height: 100,
        decoration: const BoxDecoration(
          color: CupertinoColors.systemGrey4,
          borderRadius: BorderRadius.all(Radius.circular(10)),
        ),
      ),
      const Padding(
        padding: EdgeInsets.symmetric(vertical: 6),
        child: Text('新聞來源',
            style: TextStyle(
                fontSize: 16, color: CupertinoColors.black)),
      ),
    ],
  ),
),

驗收成果

https://i.imgur.com/01I8F8S.gif

太棒了,我們成功的做出了可水平滑動的新聞來源列表了!接下來就等套用資料來顯示資訊拉。

GridView

介紹完 ListView 後,接下來就換 GridView 拉。是用於顯示網格佈局的 widget,適合用於顯示多項目的網格,並且也同樣可用於水平或垂直方向的捲動內容。

GridView 同樣的在類別定義中,也提供了多種建構子:

  1. GridView:建構網格列表,可調整網格間的間距、每行可有幾個網格
GridView(
  // gridDelegate 是用於建構網格中子項目排版的方法
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,   // 每行 2 固定兩個網格
    mainAxisSpacing: 8,  // grid 預設方向也為 vertical,因此若無指定方向則表示 vertical 項目的間距
    crossAxisSpacing: 8, // 與上方參數相反,表示 horizontal 項目的間距
  ),
  children: [ ... ]
)
  1. GridView.count:與上方的建構子相似,但其建構子實作了 gridDelegate 的參數內容,因此更方便使用。
GridView.count(
  crossAxisCount: 2,
  mainAxisSpacing: 8,
  crossAxisSpacing: 8,
  children: [ ... ]
)
  1. GridView.extent:可以指定每個子元素的最大寬度。內部也同樣實作了 gridDelegate 的參數內容
GridView.extent(
  maxCrossAxisExtent: 150, // 每個子元素最大的寬度為 150
  children: [ ... ]
)
  1. GridView.builder :用於動態生成子元素的 GridView,透過 itemBuilder 建構子元素內容。適合用於建構當子元素數量較大的情境,因為該建構子僅會對實際顯示於螢幕上的內容進行調用。
GridView.builder(
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,   // 每行 2 固定兩個網格
    mainAxisSpacing: 8,  // grid 預設方向也為 vertical,因此若無指定方向則表示 vertical 項目的間距
    crossAxisSpacing: 8, // 與上方參數相反,表示 horizontal 項目的間距
  ),
  children: [ ... ]
)

在認識完了 GridView 的各種建構子後,我們可以邁入實作階段,將我們設計圖中「新聞來源」區塊上方的「新聞分類」以 GridView 的方式來實現。

// 同樣也要製作「新聞分類」的標題,程式碼的部分就省略拉
GridView.count(
  crossAxisCount: 2,         // 每行有兩個網格
  mainAxisSpacing: 16,       // vertical 元素間隔 16 單位
  crossAxisSpacing: 16,      // horizontal 元素間格 16 單位
  childAspectRatio: 16 / 9,  // 設定每個網格項目的長寬比為 16 : 9
  shrinkWrap: true,
  
  padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), // GridView 對外的間隔
  children: [
      // 製作灰底圓角的區塊,可複製多份檢視結果
      Container(
        decoration: const BoxDecoration(
          color: CupertinoColors.systemGrey4,
          borderRadius: BorderRadius.all(Radius.circular(10)),
        ),
      )
    ]
)

值得關注的是我們給定了一個參數 shrinkWraptrue。這個的目的與上方使用 SizedBox 來限制 ListView 的顯示區域相似。

shrinkWrap 參數為:

  • false (預設) - 滾動區域會佔據整個父容器的可用空間,適合用於無限捲動的情境
  • true - 滾動區域會根據內容實際大小來調整自身大小,適合用於限定高度之內容

若未指定 shrinkWrap 這個參數成 true,則會套用預設 false,也就是會無視 children 中放置的內容,盡可能的使用最大高度空間來顯示內容,就會遇到前面所說的導致 columnunbound 錯誤。

讓我們先來看看目前的結果:

https://i.imgur.com/cUVlGKg.gif

看出來其他奇怪的地方了嗎?當我在網格的區域內滑動時會只有針對網格來滑動而非整個頁面,如果要讓整個頁面滑動則必須在網格區域外操作才能觸發。

這時候我們再加一個參數在 GridView.count 中,用以標明 GridView 不可被滾動,要滾動的是父容器,也就是整個頁面。

physics: const NeverScrollableScrollPhysics()

再試一次,現在整個頁面都可以進行滑動拉!讚讚~

Sliver widget

Sliver 有別於一般的佈局元件,可自定義滾動的效果,像是可伸縮的定端工具列、動態列表,這些效果可透過不同類型的 sliver widget 來呈現。

我們在前面撰寫應用程式外框時即有用到此概念,我們為了達成 iOS 頂部工具列的動畫而使用了 CupertinoSliverNavigationBar 此一 widget,該 widget 被包在一個 CustomScrollView 的參數 slivers 中即是表示放置於其中的子元素為可滾動的內容,我們才能達成最終我們所要的結果。

Sliver 照字面翻譯的意思就是「使...變為薄片」,也就是將要顯示的元素切成一個一個薄片,僅有當該元素需要被顯示時才進行渲染及佈局。可以有效的節省渲染畫面的壓力從而增加效能。

所以我們目前在 browse_screen.dart 最外層的 SliverToBoxAdapter 就是其中一個 sliver widget,其目的是用於將普通的 widget 包成 sliver 的工具,使的我們可以將普通 widget 放置於 CustomScrollView 中。

今日總結

今天我們介紹了 Flutter 的數個可滾動組件,包括了:

  • ListView:用於顯示列表的元件,可根據顯示的為靜態或動態內容來選擇對應的建構子
  • GridView:用於顯示網格的元件,與 ListView 的用法很類似,不過額外需指定每個 row 可顯示的網格數量及網格間的間距
  • Sliver widget:可自定義滾動元件的顯示內容,並可有效的增進效能。不過其涵蓋的範圍很廣,幾乎每種 RederBox 皆有其相對應的 Sliver 版本元件,有興趣可至此網站來拜讀。

此外我們也完成了當前「探索頁面」於設計稿上的樣式,不過目前看起來還缺很多東西,包括有哪些新聞來源、新聞分類?總不可能完全靠寫死的資料,因此我們明天打算來開始串 API 來動態的取得這些資訊,明天再接再厲拉!

今天的參考程式碼:https://github.com/ChungHanLin/micro_news_tutorial/tree/day15/micro_news_app


上一篇
[Day 14] 實戰新聞 APP - 導覽列、主題與字體設定
下一篇
[Day 16] 實戰新聞 APP - 串接 API
系列文
Flutter 從零到實戰 - 30 天の學習筆記30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言